Skip to content

feat(pow): add relay-load-aware adaptive PoW difficulty - #756

Open
Priyanshubhartistm wants to merge 4 commits into
cameri:mainfrom
Priyanshubhartistm:feat/adaptive-pow-pipeline
Open

feat(pow): add relay-load-aware adaptive PoW difficulty#756
Priyanshubhartistm wants to merge 4 commits into
cameri:mainfrom
Priyanshubhartistm:feat/adaptive-pow-pipeline

Conversation

@Priyanshubhartistm

@Priyanshubhartistm Priyanshubhartistm commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Description

nostream already has basic NIP-13 PoW verification it checks leading zero bits on event IDs and pubkeys against a static minLeadingZeroBits config value. The difficulty is fixed, so it's either too low (useless during a spam flood) or too high (annoying during quiet periods).

This adds an adaptive difficulty layer that scales the required PoW between a configured floor and ceiling based on the observed event rate, tracked per worker process using the same EWMA shape already used by the relay's rate limiter, kept purely in-process rather than Redis-backed.
When enabled, the computed difficulty replaces the static eventId/pubkey minLeadingZeroBits checks entirely one difficulty applied uniformly to both. Disabled by default.

static-mirroring-worker.ts's own PoW check is left untouched it evaluates events already accepted by an upstream relay, a different trust context the adaptive/load-aware framing doesn't obviously apply to.

Related Issue

Closes #755

Motivation and Context

A fixed PoW difficulty can't respond to actual relay load it's either a no-op during quiet periods or an unnecessary burden on legitimate users during a spam flood. This lets operators set a reasonable floor/ceiling and let the relay adjust automatically instead of hand-tuning one static number.

Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e454e26

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
nostream Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coveralls

coveralls commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Coverage Status

coverage: 72.364% (+0.1%) from 72.235% — Priyanshubhartistm:feat/adaptive-pow-pipeline into cameri:main

@phoenix-server phoenix-server left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: request changes

Independent two-axis review (correctness/security + conventions). The feature design is sound and well-documented (soft per-worker gate, opt-in, disabled by default, changeset + CONFIGURATION.md present), CI is green, and the tests pin real boundaries. But the core mapping does not do what its name, docs, and defaults promise, and two integration points were missed. Details below; the two blocking items are #1 and #2.

Blocking

1. The difficulty mapping is mis-scaled ~87x at defaults. recordEvent feeds each event to calculateEWMA with step=1 and no time normalization, so rate is a recency-weighted event count (steady state at R sustained events/sec is R * periodMs/1000/ln(2), about 86.6 * R at the default 60000 ms half-life), but getCurrentDifficulty compares it directly against targetEventsPerSecond, a per-second rate. Simulated with the PR code and shipped defaults (target 50, floor 0, ceiling 24): 0.5 sustained events/sec → difficulty 0; 1 event/sec → 18 bits; 2 events/sec → pinned at the 24-bit ceiling. The docs promise the ceiling at 2x target = 100 events/sec. On any live relay this degenerates into a permanent 24-bit requirement (~16.7M hashes/event). The tests pass only because they use same-instant bursts and tiny targets. Suggested fix: normalize before comparing (eps = rate / (periodMs / 1000 / Math.LN2)), or rename the setting to a per-EWMA-window unit, fix the default and docs to match, and add a unit test with time-spaced events (fake timers, one per second) pinning floor-at-target.

2. No floor clamp / no semantic config validation. getCurrentDifficulty clamps at ceilingBits but never at floorBits, and validateSettings only shape-checks against default-settings.yaml. With floorBits: 20, ceilingBits: 10: difficulty 10 at rate 55, 0 at rate 150, -10 at rate 200 — since the check is pow < requiredBits, a negative requirement never rejects, so PoW silently disables exactly when the relay is loaded. Suggested fix: Math.max(config.floorBits, Math.min(config.ceilingBits, scaled)), plus semantic validation (0 <= floorBits <= ceilingBits, periodMs > 0, targetEventsPerSecond > 0) in validateSettings.

Major

3. NIP-11 metadata not updated (src/handlers/request-handlers/root-request-handler.ts, outside this diff): min_pow_difficulty is advertised from the static minLeadingZeroBits only, and restricted_writes is computed from the static bits only. With adaptive enabled and static bits unset, clients see no PoW requirement while events start getting rejected under load. Suggest advertising floorBits when pow.enabled (noting the live requirement can be higher) and counting pow.enabled toward restricted_writes.

4. recordAdaptivePowEvent fires before acceptance, so signature-valid events rejected for PoW, blacklist, auth, NIP-05, or dedup all count toward difficulty. Per-pubkey rate limits do not bound the aggregate, so an attacker rotating pubkeys can push every user to ceiling difficulty with cheap unmined spam, and ordinary client retries inflate the gate. If counting rejected load is deliberate (defensible: it is a CPU-cost proxy, signatures were already verified), please document that semantics in CONFIGURATION.md; otherwise record only accepted events.

Minor / nits (inline below)

  • CONFIGURATION.md: the four limits.event.pow rows break the table's own alphabetical-order rule.
  • src/@types/settings.ts: TSDoc says "on top of" the static checks, but the feature replaces them while enabled.
  • CONFIGURATION.md: "ignored while pow.enabled is true" is only true for the client path; static-mirroring-worker.ts still enforces static bits for mirrored events. Worth a parenthetical for mirror operators.

Verified fine

EWMA cold-start (the lastEventAt = 0 first-call trick), inclusive-at-target floor, Math.ceil rounding (defender-favorable), ceiling clamp, per-worker in-process design (deliberate and disclosed in the changeset), config plumbing and defaults, and the new unit tests otherwise pin real boundaries. The test gap that let #1/#2 through CI: no time-spaced events and no floor-over-ceiling case.

Comment thread src/utils/adaptive-pow.ts Outdated
Comment thread src/utils/adaptive-pow.ts Outdated
Comment thread src/handlers/event-message-handler.ts
Comment thread src/@types/settings.ts Outdated
Comment thread CONFIGURATION.md Outdated
Comment thread CONFIGURATION.md
Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: adaptive PoW difficulty based on relay load (NIP-13)

3 participants